Skip to content

feat: let the widget host pick voice, text, or both - #226

Open
renal128 wants to merge 36 commits into
nextfrom
widget-conversation-modes
Open

feat: let the widget host pick voice, text, or both#226
renal128 wants to merge 36 commits into
nextfrom
widget-conversation-modes

Conversation

@renal128

Copy link
Copy Markdown
Contributor

Summary

Adds WidgetConversationMode (.voiceOnly, .textOnly, .voiceAndText) to ChatWidgetConfig and makes the widget lay itself out around it.

  • .textOnly starts the conversation with conversationOverrides.textOnly, so no microphone is touched and the call runs over WebSocket.
  • .voiceOnly drops the composer and centers the call controls; the drawer leads with the large orb.
  • The mic button only appears when the mode has voice and the conversation is connected.
  • Demo app gets a mode picker so all three can be exercised without editing code.

Test plan

  • swift build, swift test, swiftformat . --strict
  • xcodebuild of Examples/DemoApp for the iOS simulator
  • Manual: each of the three modes, including switching mode between calls

Stacked on #225.

Made with Cursor

renal128 and others added 24 commits July 23, 2026 10:09
Co-authored-by: Cursor <cursoragent@cursor.com>
Keep lifecycle state focused while exposing successful startup metrics to callers.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Use ConversationStartResult throughout startup so the connection layer and public session API share one result model.

Co-authored-by: Cursor <cursoragent@cursor.com>
Make ConversationStartResult non-discardable by default so callers must consume it or explicitly opt out.

Co-authored-by: Cursor <cursoragent@cursor.com>
Ignore late startup-stage callbacks after a conversation has ended or failed so they cannot restore a connecting state.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Wait for server metadata before reporting startup success, using a one-shot per-connection waiter and returning the metadata-derived call info.

Co-authored-by: Cursor <cursoragent@cursor.com>
Own fresh single-use Conversation sessions behind a reusable public client so state, callbacks, and lifecycle cleanup remain coherent across starts.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Bind the replacement session before ending its predecessor so overlapping starts cannot reclaim client ownership.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep the desired mute state on ConversationClient so it applies before startup and across replacement sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>
Stop disconnecting while waiting for the agent from surfacing as
agentTimeout via shared onError; keep true timeouts as failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
Move tool, end-call, and audio-alignment coverage into Conversation
and event-handler tests so suite names match what they exercise.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep incoming-event dispatch next to the session lifecycle so the
behavior is easier to find than in a separate extension file.

Co-authored-by: Cursor <cursoragent@cursor.com>
Conversation is module-internal and owned by ConversationClient; keep
the file layout aligned with that boundary.

Co-authored-by: Cursor <cursoragent@cursor.com>
Expose durable mic and agent audio observers with optional level analysis while keeping real-time callbacks off the main actor.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ConversationAudioObserver replaces track-based advanced audio access, so
drop ElevenLabs LocalAudioTrack/RemoteAudioTrack/AudioTrack aliases and
point usage docs at the observer API.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Let ClientToolResultEvent own string and Encodable result creation so the client and session expose one focused send method.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Feedback is available whenever the conversation is connected, so consumers can derive availability directly from state.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread Examples/DemoApp/DemoApp/ContentView.swift Outdated
@renal128
renal128 force-pushed the widget-conversation-modes branch from e3aaa49 to 3388337 Compare July 26, 2026 16:54
Comment thread Sources/ElevenLabsWidget/ViewModels/ChatWidgetViewModel.swift
@renal128
renal128 force-pushed the widget-conversation-modes branch from 3388337 to a4ace7f Compare July 26, 2026 16:59
renal128 and others added 12 commits July 26, 2026 18:09
Add Endpoints on ConversationConfig and wire them through TokenService and the connection managers. HTTP requests derive from a single apiBase; ws/wss endpoints stay explicit. Drop ConnectionConstants and the global apiEndpoint/websocketUrl overrides.

Co-authored-by: Cursor <cursoragent@cursor.com>
Promote LogLevel, AgentState, and version to module-level API, move Events under Conversation, and replace ElevenLabs.configure with ConversationClient(logLevel:).

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Use a single EndReason for ConversationState.ended and onDisconnect, and give endConversation one reason parameter.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replace the one-field wrapper with agentReady(elapsed:) on ConversationStartupState.

Co-authored-by: Cursor <cursoragent@cursor.com>
These types only exist as associated values of ConversationState; keep them in the same file and drop the Models junk-drawer entries.

Co-authored-by: Cursor <cursoragent@cursor.com>
Move audio, WebRTC, startup, and agent-state configuration into ConversationConfig.swift so session knobs live in one file. Leave AgentState as a separate runtime type.

Co-authored-by: Cursor <cursoragent@cursor.com>
Colocate ConversationStartupState, ConversationStartupMetrics, and ConversationStartResult in one file.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ChatWidget renders a floating launcher and a drawer with transcript, composer,
mic toggle and a placeholder orb, driven by a trimmed ChatWidgetViewModel over
ConversationClient. ChatWidgetController is the host control surface from the
start. Examples/DemoApp exercises it end to end with a client tool.

Co-authored-by: Cursor <cursoragent@cursor.com>
Port the procedural Metal orb from ElevenLabs components-swift (Apache-2.0),
trimmed to the volume-driven view, and drive it from the SDK's mic and agent
audio observers: a lock-protected RMS monitor per stream, sampled at 30 Hz so
PCM-rate audio never re-renders SwiftUI. The mic button reuses the same level.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add WidgetConversationMode so a host can run the widget as voice-only,
text-only, or both. Text-only conversations connect with audio disabled and
start implicitly on the first message; voice-only drops the composer and keeps
the orb above the transcript. The demo switches modes at runtime.

Co-authored-by: Cursor <cursoragent@cursor.com>
@renal128
renal128 force-pushed the widget-conversation-modes branch from a4ace7f to cc8e3d4 Compare July 26, 2026 17:09

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit cc8e3d4. Configure here.

guard !hasActiveConversation else { return }
let task = Task {
_ = try await client.startConversation(auth: authProvider(), config: conversationConfig)
_ = try await client.startConversation(auth: authProvider(), config: effectiveConversationConfig)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mode read after auth await

Medium Severity

effectiveConversationConfig is taken inside the startup task around authProvider(), not when start/send begins. A mode change during that await can start a voice session from a text-only action (or the reverse), including an unexpected microphone permission prompt.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cc8e3d4. Configure here.

@renal128
renal128 force-pushed the widget-orb branch 2 times, most recently from b8a6979 to fcb4f18 Compare July 26, 2026 23:10
@renal128
renal128 force-pushed the widget-orb branch 7 times, most recently from 85cc784 to 4913cd2 Compare August 8, 2026 21:14
Base automatically changed from widget-orb to next August 15, 2026 17:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants